🎖️GitЯра🎖️
Commit 7730823aad5ffe461199820be209e18d09b0ee07
Parents : 981290b
Author : simulationstation <32910678+simulationstation@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-08-13T18:17:33Z
Committer : GitHub <noreply@github.com>
Date : 2026-08-13T18:17:33Z
refactor(maps): share custom tile providers (#6641)
Changes
18 files changed, 1268 insertions(+), 518 deletions(-)
Diff
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt
index 13a7c533a3..3b07718609 100644
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt
@@ -293,7 +293,7 @@ fun MapView(
var boxAuthoringSecondCorner by remember { mutableStateOf<LatLng?>(null) }
val selectedGoogleMapType by mapViewModel.selectedGoogleMapType.collectAsStateWithLifecycle()
- val currentCustomTileProviderUrl by mapViewModel.selectedCustomTileProviderUrl.collectAsStateWithLifecycle()
+ val currentCustomTileProvider by mapViewModel.selectedCustomTileProvider.collectAsStateWithLifecycle()
var mapTypeMenuExpanded by remember { mutableStateOf(false) }
var showCustomTileManagerSheet by remember { mutableStateOf(false) }
@@ -583,7 +583,7 @@ fun MapView(
val onRemoveLayer = { layerId: String -> mapViewModel.removeMapLayer(layerId) }
val onToggleVisibility = { layerId: String -> mapViewModel.toggleLayerVisibility(layerId) }
- val effectiveGoogleMapType = if (currentCustomTileProviderUrl != null) MapType.NONE else selectedGoogleMapType
+ val effectiveGoogleMapType = if (currentCustomTileProvider != null) MapType.NONE else selectedGoogleMapType
var showClusterItemsDialog by remember { mutableStateOf<List<NodeClusterItem>?>(null) }
@@ -645,12 +645,8 @@ fun MapView(
},
) {
// Custom tile overlay (all modes)
- key(currentCustomTileProviderUrl) {
- currentCustomTileProviderUrl?.let { url ->
- val config =
- mapViewModel.customTileProviderConfigs.collectAsStateWithLifecycle().value.find {
- it.urlTemplate == url || it.localUri == url
- }
+ key(currentCustomTileProvider) {
+ currentCustomTileProvider?.let { config ->
mapViewModel.getTileProvider(config)?.let { tileProvider ->
TileOverlay(tileProvider = tileProvider, fadeIn = true, transparency = 0f, zIndex = -1f)
}
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt
index 5276d65eb7..965cc789b6 100644
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt
@@ -35,18 +35,23 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.app.map.model.CustomTileProviderConfig
+import org.meshtastic.app.map.model.isValidTileUrlTemplate
import org.meshtastic.app.map.prefs.map.GoogleCameraPosition
+import org.meshtastic.app.map.prefs.map.GoogleMapSelectionPrefs
import org.meshtastic.app.map.prefs.map.GoogleMapsPrefs
import org.meshtastic.app.map.repository.CustomTileProviderRepository
+import org.meshtastic.app.map.repository.CustomTileProviderSaveResult
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.repository.MapPrefs
+import org.meshtastic.core.repository.MapTileProviderPrefs
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.NotificationPrefs
import org.meshtastic.core.repository.PacketRepository
@@ -84,6 +89,7 @@ class MapViewModel(
radioConfigRepository: RadioConfigRepository,
radioController: RadioController,
private val customTileProviderRepository: CustomTileProviderRepository,
+ private val mapTileProviderPrefs: MapTileProviderPrefs,
uiPrefs: UiPrefs,
notificationPrefs: NotificationPrefs,
savedStateHandle: SavedStateHandle,
@@ -141,8 +147,14 @@ class MapViewModel(
val customTileProviderConfigs: StateFlow<List<CustomTileProviderConfig>> =
customTileProviderRepository.getCustomTileProviders().stateInWhileSubscribed(initialValue = emptyList())
- private val _selectedCustomTileProviderUrl = MutableStateFlow<String?>(null)
- val selectedCustomTileProviderUrl: StateFlow<String?> = _selectedCustomTileProviderUrl.asStateFlow()
+ private val _selectedCustomTileProviderId = MutableStateFlow<String?>(null)
+ val selectedCustomTileProviderId: StateFlow<String?> = _selectedCustomTileProviderId.asStateFlow()
+
+ val selectedCustomTileProvider: StateFlow<CustomTileProviderConfig?> =
+ combine(_selectedCustomTileProviderId, customTileProviderConfigs) { selectedId, providers ->
+ providers.findSelectedCustomTileProvider(selectedId)
+ }
+ .stateInWhileSubscribed(initialValue = null)
private val _selectedGoogleMapType = MutableStateFlow(MapType.NORMAL)
val selectedGoogleMapType: StateFlow<MapType> = _selectedGoogleMapType.asStateFlow()
@@ -157,11 +169,6 @@ class MapViewModel(
_errorFlow.emit("Invalid name, URL template, or local URI for custom tile provider.")
return@launch
}
- if (customTileProviderConfigs.value.any { it.name.equals(name, ignoreCase = true) }) {
- _errorFlow.emit("Custom tile provider with name '$name' already exists.")
- return@launch
- }
-
var finalLocalUri = localUri
if (localUri != null) {
try {
@@ -185,44 +192,55 @@ class MapViewModel(
}
val newConfig = CustomTileProviderConfig(name = name, urlTemplate = urlTemplate, localUri = finalLocalUri)
- customTileProviderRepository.addCustomTileProvider(newConfig)
+ when (customTileProviderRepository.addCustomTileProvider(newConfig)) {
+ CustomTileProviderSaveResult.SAVED -> Unit
+
+ CustomTileProviderSaveResult.DUPLICATE_NAME -> {
+ finalLocalUri?.let { deleteFileToInternalStorage(Uri.parse(it)) }
+ _errorFlow.emit("Custom tile provider with name '$name' already exists.")
+ }
+
+ CustomTileProviderSaveResult.NOT_FOUND -> _errorFlow.emit("Failed to save custom tile provider.")
+ }
}
}
- fun updateCustomTileProvider(configToUpdate: CustomTileProviderConfig) {
+ fun addCustomTileProvider(config: CustomTileProviderConfig) {
viewModelScope.launch {
- if (
- configToUpdate.name.isBlank() ||
- (configToUpdate.urlTemplate.isBlank() && configToUpdate.localUri == null) ||
- (configToUpdate.localUri == null && !isValidTileUrlTemplate(configToUpdate.urlTemplate))
- ) {
- _errorFlow.emit("Invalid name, URL template, or local URI for updating custom tile provider.")
+ val normalized = config.normalized()
+ if (normalized.name.isBlank() || !normalized.hasValidGoogleTileSource()) {
+ _errorFlow.emit("Invalid custom tile provider configuration.")
return@launch
}
- val existingConfigs = customTileProviderConfigs.value
+ when (customTileProviderRepository.addCustomTileProvider(normalized)) {
+ CustomTileProviderSaveResult.SAVED -> Unit
+
+ CustomTileProviderSaveResult.DUPLICATE_NAME ->
+ _errorFlow.emit("Custom tile provider with that name already exists.")
+
+ CustomTileProviderSaveResult.NOT_FOUND -> _errorFlow.emit("Failed to save custom tile provider.")
+ }
+ }
+ }
+
+ fun updateCustomTileProvider(configToUpdate: CustomTileProviderConfig) {
+ viewModelScope.launch {
+ val normalized = configToUpdate.normalized()
if (
- existingConfigs.any {
- it.id != configToUpdate.id && it.name.equals(configToUpdate.name, ignoreCase = true)
- }
+ normalized.name.isBlank() ||
+ (normalized.urlTemplate.isBlank() && normalized.localUri == null) ||
+ (normalized.localUri == null && !isValidTileUrlTemplate(normalized.urlTemplate))
) {
- _errorFlow.emit("Another custom tile provider with name '${configToUpdate.name}' already exists.")
+ _errorFlow.emit("Invalid name, URL template, or local URI for updating custom tile provider.")
return@launch
}
+ when (customTileProviderRepository.updateCustomTileProvider(normalized)) {
+ CustomTileProviderSaveResult.SAVED -> Unit
- customTileProviderRepository.updateCustomTileProvider(configToUpdate)
+ CustomTileProviderSaveResult.DUPLICATE_NAME ->
+ _errorFlow.emit("Another custom tile provider with name '${normalized.name}' already exists.")
- val originalConfig = customTileProviderRepository.getCustomTileProviderById(configToUpdate.id)
- if (
- _selectedCustomTileProviderUrl.value != null &&
- originalConfig?.urlTemplate == _selectedCustomTileProviderUrl.value
- ) {
- // No change needed if URL didn't change, or handle if it did
- } else if (originalConfig != null && _selectedCustomTileProviderUrl.value != originalConfig.urlTemplate) {
- val currentlySelectedConfig =
- customTileProviderConfigs.value.find { it.urlTemplate == _selectedCustomTileProviderUrl.value }
- if (currentlySelectedConfig?.id == configToUpdate.id) {
- _selectedCustomTileProviderUrl.value = configToUpdate.urlTemplate
- }
+ CustomTileProviderSaveResult.NOT_FOUND -> _errorFlow.emit("Custom tile provider no longer exists.")
}
}
}
@@ -230,16 +248,17 @@ class MapViewModel(
fun removeCustomTileProvider(configId: String) {
viewModelScope.launch {
val configToRemove = customTileProviderRepository.getCustomTileProviderById(configId)
+ val wasSelected = _selectedCustomTileProviderId.value == configId
customTileProviderRepository.deleteCustomTileProvider(configId)
if (configToRemove != null) {
- if (
- _selectedCustomTileProviderUrl.value == configToRemove.urlTemplate ||
- _selectedCustomTileProviderUrl.value == configToRemove.localUri
- ) {
- _selectedCustomTileProviderUrl.value = null
- // Also clear from prefs
+ if (wasSelected) {
+ clearCurrentTileProvider()
+ _selectedCustomTileProviderId.value = null
+ _selectedGoogleMapType.value = MapType.NORMAL
+ mapTileProviderPrefs.setSelectedCustomTileProviderId(null)
googleMapsPrefs.setSelectedCustomTileUrl(null)
+ googleMapsPrefs.setSelectedGoogleMapType(MapType.NORMAL.name)
}
if (configToRemove.localUri != null) {
@@ -253,48 +272,53 @@ class MapViewModel(
fun selectCustomTileProvider(config: CustomTileProviderConfig?) {
if (config != null) {
if (!config.isLocal && !isValidTileUrlTemplate(config.urlTemplate)) {
- Logger.withTag("MapViewModel").w("Attempted to select invalid URL template: ${config.urlTemplate}")
- _selectedCustomTileProviderUrl.value = null
+ Logger.withTag("MapViewModel").w("Attempted to select an invalid custom tile URL template")
+ clearCurrentTileProvider()
+ _selectedCustomTileProviderId.value = null
+ _selectedGoogleMapType.value = MapType.NORMAL
+ viewModelScope.launch { mapTileProviderPrefs.setSelectedCustomTileProviderId(null) }
googleMapsPrefs.setSelectedCustomTileUrl(null)
+ googleMapsPrefs.setSelectedGoogleMapType(MapType.NORMAL.name)
return
}
- // Use localUri if present, otherwise urlTemplate
- val selectedUrl = config.localUri ?: config.urlTemplate
- _selectedCustomTileProviderUrl.value = selectedUrl
+ _selectedCustomTileProviderId.value = config.id
_selectedGoogleMapType.value = MapType.NONE
- googleMapsPrefs.setSelectedCustomTileUrl(selectedUrl)
+ viewModelScope.launch { mapTileProviderPrefs.setSelectedCustomTileProviderId(config.id) }
+ googleMapsPrefs.setSelectedCustomTileUrl(null)
googleMapsPrefs.setSelectedGoogleMapType(null)
} else {
- _selectedCustomTileProviderUrl.value = null
+ clearCurrentTileProvider()
+ _selectedCustomTileProviderId.value = null
_selectedGoogleMapType.value = MapType.NORMAL
+ viewModelScope.launch { mapTileProviderPrefs.setSelectedCustomTileProviderId(null) }
googleMapsPrefs.setSelectedCustomTileUrl(null)
googleMapsPrefs.setSelectedGoogleMapType(MapType.NORMAL.name)
}
}
fun setSelectedGoogleMapType(mapType: MapType) {
+ clearCurrentTileProvider()
_selectedGoogleMapType.value = mapType
- _selectedCustomTileProviderUrl.value = null // Clear custom selection
+ _selectedCustomTileProviderId.value = null
+ viewModelScope.launch { mapTileProviderPrefs.setSelectedCustomTileProviderId(null) }
googleMapsPrefs.setSelectedGoogleMapType(mapType.name)
googleMapsPrefs.setSelectedCustomTileUrl(null)
}
private var currentTileProvider: TileProvider? = null
+ private var currentTileProviderConfig: CustomTileProviderConfig? = null
fun getTileProvider(config: CustomTileProviderConfig?): TileProvider? {
if (config == null) {
- (currentTileProvider as? MBTilesProvider)?.close()
- currentTileProvider = null
+ clearCurrentTileProvider()
return null
}
- val selectedUrl = config.localUri ?: config.urlTemplate
- if (currentTileProvider != null && _selectedCustomTileProviderUrl.value == selectedUrl) {
+ if (currentTileProvider != null && currentTileProviderConfig == config) {
return currentTileProvider
}
- // Close previous if it was a local provider
- (currentTileProvider as? MBTilesProvider)?.close()
+ clearCurrentTileProvider()
val newProvider =
if (config.isLocal) {
@@ -308,14 +332,13 @@ class MapViewModel(
if (file.exists()) {
MBTilesProvider(file)
} else {
- Logger.withTag("MapViewModel").e("Local MBTiles file does not exist: ${config.localUri}")
+ Logger.withTag("MapViewModel").w("Selected local MBTiles file does not exist")
null
}
} else {
val urlString = config.urlTemplate
if (!isValidTileUrlTemplate(urlString)) {
- Logger.withTag("MapViewModel")
- .e("Tile URL does not contain valid {x}, {y}, and {z} placeholders: $urlString")
+ Logger.withTag("MapViewModel").w("Selected custom tile URL template is invalid")
null
} else {
object : UrlTileProvider(TILE_SIZE, TILE_SIZE) {
@@ -330,8 +353,8 @@ class MapViewModel(
.replace("{y}", y.toString(), ignoreCase = true)
return try {
URL(formattedUrl)
- } catch (e: MalformedURLException) {
- Logger.withTag("MapViewModel").e(e) { "Malformed URL: $formattedUrl" }
+ } catch (_: MalformedURLException) {
+ Logger.withTag("MapViewModel").w("Custom tile provider produced a malformed URL")
null
}
}
@@ -340,12 +363,18 @@ class MapViewModel(
}
currentTileProvider = newProvider
+ currentTileProviderConfig = config.takeIf { newProvider != null }
return newProvider
}
- private fun isValidTileUrlTemplate(urlTemplate: String): Boolean = urlTemplate.contains("{z}", ignoreCase = true) &&
- urlTemplate.contains("{x}", ignoreCase = true) &&
- urlTemplate.contains("{y}", ignoreCase = true)
+ private fun isValidTileUrlTemplate(urlTemplate: String): Boolean =
+ urlTemplate.isValidTileUrlTemplate(requireHttps = false)
+
+ private fun clearCurrentTileProvider() {
+ (currentTileProvider as? MBTilesProvider)?.close()
+ currentTileProvider = null
+ currentTileProviderConfig = null
+ }
/** Imported overlay layers; owned by the flavor-neutral [MapLayersManager] and rendered by [MapLayerOverlay]. */
val mapLayers: StateFlow<List<MapLayerItem>> = mapLayersManager.mapLayers
@@ -362,8 +391,13 @@ class MapViewModel(
}
viewModelScope.launch {
- customTileProviderRepository.getCustomTileProviders().first()
- loadPersistedMapType()
+ val providerLoad = customTileProviderRepository.awaitCustomTileProviders()
+ loadPersistedMapType(
+ providers = providerLoad.providers,
+ providerLoadSuccessful = providerLoad.isSuccessful,
+ selection = googleMapsPrefs.awaitMapSelection(),
+ selectedProviderId = mapTileProviderPrefs.awaitSelectedCustomTileProviderId(),
+ )
}
selectedWaypointId.value?.let { wpId ->
@@ -388,30 +422,42 @@ class MapViewModel(
saveCameraPosition(cameraPositionState.position)
}
- private fun loadPersistedMapType() {
- val savedCustomUrl = googleMapsPrefs.selectedCustomTileUrl.value
- if (savedCustomUrl != null) {
- // Check if this custom provider still exists
- if (
- customTileProviderConfigs.value.any { it.urlTemplate == savedCustomUrl } &&
- isValidTileUrlTemplate(savedCustomUrl)
- ) {
- _selectedCustomTileProviderUrl.value = savedCustomUrl
- _selectedGoogleMapType.value =
- MapType.NONE // MapType.NONE to hide google basemap when using custom provider
- } else {
- // The saved custom URL is no longer valid or doesn't exist, remove preference
- googleMapsPrefs.setSelectedCustomTileUrl(null)
- // Fallback to default Google Map type
- _selectedGoogleMapType.value = MapType.NORMAL
+ private suspend fun loadPersistedMapType(
+ providers: List<CustomTileProviderConfig>,
+ providerLoadSuccessful: Boolean,
+ selection: GoogleMapSelectionPrefs,
+ selectedProviderId: String?,
+ ) {
+ val resolvedSelection =
+ providers.resolvePersistedCustomTileSelection(
+ selectedProviderId = selectedProviderId,
+ legacySource = selection.customTileUrl,
+ providerLoadSuccessful = providerLoadSuccessful,
+ )
+ val selectedProvider = resolvedSelection.provider
+
+ if (selectedProvider != null) {
+ _selectedCustomTileProviderId.value = selectedProvider.id
+ _selectedGoogleMapType.value = MapType.NONE
+ if (selectedProviderId != selectedProvider.id) {
+ mapTileProviderPrefs.setSelectedCustomTileProviderId(selectedProvider.id)
}
+ if (selection.customTileUrl != null) googleMapsPrefs.setSelectedCustomTileUrl(null)
} else {
- val savedGoogleMapTypeName = googleMapsPrefs.selectedGoogleMapType.value
+ _selectedCustomTileProviderId.value = null
+ if (resolvedSelection.canDiscardMissingSelection) {
+ if (selectedProviderId != null) mapTileProviderPrefs.setSelectedCustomTileProviderId(null)
+ if (selection.customTileUrl != null) googleMapsPrefs.setSelectedCustomTileUrl(null)
+ }
+ if (!providerLoadSuccessful && (selectedProviderId != null || selection.customTileUrl != null)) {
+ _selectedGoogleMapType.value = MapType.NORMAL
+ return
+ }
try {
- _selectedGoogleMapType.value = MapType.valueOf(savedGoogleMapTypeName ?: MapType.NORMAL.name)
- } catch (e: IllegalArgumentException) {
- Logger.e(e) { "Invalid saved Google Map type: $savedGoogleMapTypeName" }
- _selectedGoogleMapType.value = MapType.NORMAL // Fallback in case of invalid stored name
+ _selectedGoogleMapType.value = MapType.valueOf(selection.mapType)
+ } catch (_: IllegalArgumentException) {
+ Logger.w { "Ignoring an invalid saved Google Map type" }
+ _selectedGoogleMapType.value = MapType.NORMAL
googleMapsPrefs.setSelectedGoogleMapType(null)
}
}
@@ -477,6 +523,36 @@ class MapViewModel(
override fun getUser(userId: String?) = nodeRepository.getUser(userId ?: NodeAddress.ID_BROADCAST)
}
+internal fun List<CustomTileProviderConfig>.findSelectedCustomTileProvider(
+ selectedProviderId: String?,
+): CustomTileProviderConfig? = singleOrNull { it.id == selectedProviderId }
+
+internal fun List<CustomTileProviderConfig>.findLegacyCustomTileProvider(
+ legacySource: String?,
+): CustomTileProviderConfig? = legacySource?.let { source -> firstOrNull { (it.localUri ?: it.urlTemplate) == source } }
+
+internal data class PersistedCustomTileSelection(
+ val provider: CustomTileProviderConfig?,
+ val canDiscardMissingSelection: Boolean,
+)
+
+internal fun List<CustomTileProviderConfig>.resolvePersistedCustomTileSelection(
+ selectedProviderId: String?,
+ legacySource: String?,
+ providerLoadSuccessful: Boolean,
+): PersistedCustomTileSelection {
+ val provider =
+ listOfNotNull(findSelectedCustomTileProvider(selectedProviderId), findLegacyCustomTileProvider(legacySource))
+ .firstOrNull { it.hasValidGoogleTileSource() }
+ return PersistedCustomTileSelection(
+ provider = provider,
+ canDiscardMissingSelection = provider == null && providerLoadSuccessful,
+ )
+}
+
+internal fun CustomTileProviderConfig.hasValidGoogleTileSource(): Boolean =
+ isLocal || urlTemplate.isValidTileUrlTemplate(requireHttps = false)
+
private fun GoogleCameraPosition.toCameraPosition() = CameraPosition(LatLng(targetLat, targetLng), zoom, tilt, bearing)
private fun CameraPosition.toMapCameraPosition() = GoogleCameraPosition(
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/CustomTileProviderManagerSheet.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/CustomTileProviderManagerSheet.kt
index 198a857a92..ce6520266a 100644
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/CustomTileProviderManagerSheet.kt
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/CustomTileProviderManagerSheet.kt
@@ -16,309 +16,51 @@
*/
package org.meshtastic.app.map.component
+import android.app.Activity
import android.content.Intent
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.items
-import androidx.compose.material3.Button
-import androidx.compose.material3.HorizontalDivider
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.ListItem
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.OutlinedTextField
-import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.saveable.rememberSaveable
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.collectLatest
-import org.jetbrains.compose.resources.stringResource
import org.meshtastic.app.map.MapViewModel
-import org.meshtastic.app.map.model.CustomTileProviderConfig
-import org.meshtastic.core.resources.Res
-import org.meshtastic.core.resources.add_custom_tile_source
-import org.meshtastic.core.resources.add_local_mbtiles_file
-import org.meshtastic.core.resources.cancel
-import org.meshtastic.core.resources.delete_custom_tile_source
-import org.meshtastic.core.resources.edit_custom_tile_source
-import org.meshtastic.core.resources.local_mbtiles_file
-import org.meshtastic.core.resources.manage_custom_tile_sources
-import org.meshtastic.core.resources.name
-import org.meshtastic.core.resources.name_cannot_be_empty
-import org.meshtastic.core.resources.no_custom_tile_sources_found
-import org.meshtastic.core.resources.provider_name_exists
-import org.meshtastic.core.resources.save
-import org.meshtastic.core.resources.url_cannot_be_empty
-import org.meshtastic.core.resources.url_must_contain_placeholders
-import org.meshtastic.core.resources.url_template
-import org.meshtastic.core.resources.url_template_hint
-import org.meshtastic.core.ui.component.MeshtasticDialog
-import org.meshtastic.core.ui.icon.Delete
-import org.meshtastic.core.ui.icon.Edit
-import org.meshtastic.core.ui.icon.MeshtasticIcons
+import org.meshtastic.app.map.getFileName
import org.meshtastic.core.ui.util.showToast
-@Suppress("LongMethod")
@Composable
fun CustomTileProviderManagerSheet(mapViewModel: MapViewModel) {
- val customTileProviders by mapViewModel.customTileProviderConfigs.collectAsStateWithLifecycle()
- var editingConfig by remember { mutableStateOf<CustomTileProviderConfig?>(null) }
- var showEditDialog by remember { mutableStateOf(false) }
+ val providers by mapViewModel.customTileProviderConfigs.collectAsStateWithLifecycle()
val context = LocalContext.current
-
val mbtilesPickerLauncher =
rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) { result ->
- if (result.resultCode == android.app.Activity.RESULT_OK) {
+ if (result.resultCode == Activity.RESULT_OK) {
result.data?.data?.let { uri ->
- val fileName = uri.getFileName(context)
- val baseName = fileName.substringBeforeLast('.')
mapViewModel.addCustomTileProvider(
- name = baseName,
- urlTemplate = "", // Empty for local
+ name = uri.getFileName(context).substringBeforeLast('.'),
+ urlTemplate = "",
localUri = uri.toString(),
)
}
}
}
- LaunchedEffect(Unit) { mapViewModel.errorFlow.collectLatest { errorMessage -> context.showToast(errorMessage) } }
-
- if (showEditDialog) {
- AddEditCustomTileProviderDialog(
- config = editingConfig,
- onDismiss = { showEditDialog = false },
- onSave = { name, url ->
- if (editingConfig == null) { // Adding new
- mapViewModel.addCustomTileProvider(name, url)
- } else { // Editing existing
- mapViewModel.updateCustomTileProvider(editingConfig!!.copy(name = name, urlTemplate = url))
- }
- showEditDialog = false
- },
- mapViewModel = mapViewModel,
- )
- }
-
- LazyColumn(contentPadding = PaddingValues(bottom = 16.dp)) {
- item {
- Text(
- text = stringResource(Res.string.manage_custom_tile_sources),
- style = MaterialTheme.typography.headlineSmall,
- modifier = Modifier.padding(16.dp),
+ LaunchedEffect(Unit) { mapViewModel.errorFlow.collectLatest { context.showToast(it) } }
+
+ CustomTileProviderManager(
+ providers = providers,
+ onAdd = mapViewModel::addCustomTileProvider,
+ onUpdate = mapViewModel::updateCustomTileProvider,
+ onDelete = mapViewModel::removeCustomTileProvider,
+ onAddLocalMbTiles = {
+ mbtilesPickerLauncher.launch(
+ Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
+ addCategory(Intent.CATEGORY_OPENABLE)
+ type = "*/*"
+ },
)
- HorizontalDivider()
- }
-
- if (customTileProviders.isEmpty()) {
- item {
- Text(
- text = stringResource(Res.string.no_custom_tile_sources_found),
- modifier = Modifier.padding(16.dp),
- style = MaterialTheme.typography.bodyMedium,
- )
- }
- } else {
- items(customTileProviders, key = { it.id }) { config ->
- ListItem(
- headlineContent = { Text(config.name) },
- supportingContent = {
- if (config.isLocal) {
- Text(
- stringResource(Res.string.local_mbtiles_file),
- style = MaterialTheme.typography.bodySmall,
- )
- } else {
- Text(config.urlTemplate, style = MaterialTheme.typography.bodySmall)
- }
- },
- trailingContent = {
- Row {
- IconButton(
- onClick = {
- editingConfig = config
- showEditDialog = true
- },
- ) {
- Icon(
- MeshtasticIcons.Edit,
- contentDescription = stringResource(Res.string.edit_custom_tile_source),
- )
- }
- IconButton(onClick = { mapViewModel.removeCustomTileProvider(config.id) }) {
- Icon(
- MeshtasticIcons.Delete,
- contentDescription = stringResource(Res.string.delete_custom_tile_source),
- )
- }
- }
- },
- )
- HorizontalDivider()
- }
- }
-
- item {
- Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
- Button(
- onClick = {
- editingConfig = null
- showEditDialog = true
- },
- modifier = Modifier.fillMaxWidth(),
- ) {
- Text(stringResource(Res.string.add_custom_tile_source))
- }
-
- Button(
- onClick = {
- val intent =
- Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
- addCategory(Intent.CATEGORY_OPENABLE)
- type = "*/*"
- }
- mbtilesPickerLauncher.launch(intent)
- },
- modifier = Modifier.fillMaxWidth(),
- ) {
- Text(stringResource(Res.string.add_local_mbtiles_file))
- }
- }
- }
- }
-}
-
-@Suppress("LongMethod")
-@Composable
-private fun AddEditCustomTileProviderDialog(
- config: CustomTileProviderConfig?,
- onDismiss: () -> Unit,
- onSave: (String, String) -> Unit,
- mapViewModel: MapViewModel,
-) {
- var name by rememberSaveable { mutableStateOf(config?.name ?: "") }
- var url by rememberSaveable { mutableStateOf(config?.urlTemplate ?: "") }
- var nameError by remember { mutableStateOf<String?>(null) }
- var urlError by remember { mutableStateOf<String?>(null) }
- val customTileProviders by mapViewModel.customTileProviderConfigs.collectAsStateWithLifecycle()
-
- val emptyNameError = stringResource(Res.string.name_cannot_be_empty)
- val providerNameExistsError = stringResource(Res.string.provider_name_exists)
- val urlCannotBeEmptyError = stringResource(Res.string.url_cannot_be_empty)
- val urlMustContainPlaceholdersError = stringResource(Res.string.url_must_contain_placeholders)
-
- fun validateAndSave() {
- val currentNameError =
- validateName(name, customTileProviders, config?.id, emptyNameError, providerNameExistsError)
- val currentUrlError = validateUrl(url, urlCannotBeEmptyError, urlMustContainPlaceholdersError)
-
- nameError = currentNameError
- urlError = currentUrlError
-
- if (currentNameError == null && currentUrlError == null) {
- onSave(name, url)
- }
- }
-
- MeshtasticDialog(
- onDismiss = onDismiss,
- title =
- if (config == null) {
- stringResource(Res.string.add_custom_tile_source)
- } else {
- stringResource(Res.string.edit_custom_tile_source)
- },
- text = {
- Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
- OutlinedTextField(
- value = name,
- onValueChange = {
- name = it
- nameError = null
- },
- label = { Text(stringResource(Res.string.name)) },
- isError = nameError != null,
- supportingText = { nameError?.let { Text(it) } },
- singleLine = true,
- )
- OutlinedTextField(
- value = url,
- onValueChange = {
- url = it
- urlError = null
- },
- label = { Text(stringResource(Res.string.url_template)) },
- isError = urlError != null,
- supportingText = {
- if (urlError != null) {
- Text(urlError!!)
- } else {
- Text(stringResource(Res.string.url_template_hint))
- }
- },
- singleLine = false,
- maxLines = 2,
- )
- }
},
- onConfirm = { validateAndSave() },
- confirmTextRes = Res.string.save,
- dismissTextRes = Res.string.cancel,
)
}
-
-private fun validateName(
- name: String,
- providers: List<CustomTileProviderConfig>,
- currentId: String?,
- emptyNameError: String,
- nameExistsError: String,
-): String? = if (name.isBlank()) {
- emptyNameError
-} else if (providers.any { it.name.equals(name, ignoreCase = true) && it.id != currentId }) {
- nameExistsError
-} else {
- null
-}
-
-private fun validateUrl(url: String, emptyUrlError: String, mustContainPlaceholdersError: String): String? =
- if (url.isBlank()) {
- emptyUrlError
- } else if (
- !url.contains("{z}", ignoreCase = true) ||
- !url.contains("{x}", ignoreCase = true) ||
- !url.contains("{y}", ignoreCase = true)
- ) {
- mustContainPlaceholdersError
- } else {
- null
- }
-
-private fun android.net.Uri.getFileName(context: android.content.Context): String {
- var name = this.lastPathSegment ?: "mbtiles_file"
- if (this.scheme == "content") {
- context.contentResolver.query(this, null, null, null, null)?.use { cursor ->
- if (cursor.moveToFirst()) {
- val displayNameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
- if (displayNameIndex != -1) {
- name = cursor.getString(displayNameIndex)
- }
- }
- }
- }
- return name
-}
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/MapTypeDropdown.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/MapTypeDropdown.kt
index a649e29624..24c5b70af0 100644
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/MapTypeDropdown.kt
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/MapTypeDropdown.kt
@@ -50,7 +50,7 @@ internal fun MapTypeDropdown(
onManageCustomTileProvidersClicked: () -> Unit,
) {
val customTileProviders by mapViewModel.customTileProviderConfigs.collectAsStateWithLifecycle()
- val selectedCustomUrl by mapViewModel.selectedCustomTileProviderUrl.collectAsStateWithLifecycle()
+ val selectedCustomProviderId by mapViewModel.selectedCustomTileProviderId.collectAsStateWithLifecycle()
val selectedGoogleMapType by mapViewModel.selectedGoogleMapType.collectAsStateWithLifecycle()
val googleMapTypes =
@@ -71,7 +71,7 @@ internal fun MapTypeDropdown(
onDismissRequest()
},
trailingIcon =
- if (selectedCustomUrl == null && selectedGoogleMapType == type) {
+ if (selectedCustomProviderId == null && selectedGoogleMapType == type) {
{
Icon(
MeshtasticIcons.Check,
@@ -95,7 +95,7 @@ internal fun MapTypeDropdown(
onDismissRequest()
},
trailingIcon =
- if (selectedCustomUrl == config.urlTemplate) {
+ if (selectedCustomProviderId == config.id) {
{
Icon(
MeshtasticIcons.Check,
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/model/CustomTileProviderConfig.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/model/CustomTileProviderConfig.kt
deleted file mode 100644
index 5bd9fdf87f..0000000000
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/model/CustomTileProviderConfig.kt
+++ /dev/null
@@ -1,31 +0,0 @@
-/*
- * Copyright (c) 2026 Meshtastic LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see <https://www.gnu.org/licenses/>.
- */
-package org.meshtastic.app.map.model
-
-import kotlinx.serialization.Serializable
-import kotlin.uuid.Uuid
-
-@Serializable
-data class CustomTileProviderConfig(
- val id: String = Uuid.random().toString(),
- val name: String,
- val urlTemplate: String,
- val localUri: String? = null,
-) {
- val isLocal: Boolean
- get() = localUri != null
-}
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/prefs/map/GoogleMapsPrefs.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/prefs/map/GoogleMapsPrefs.kt
index 44daec8298..6223e7dfa7 100644
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/prefs/map/GoogleMapsPrefs.kt
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/prefs/map/GoogleMapsPrefs.kt
@@ -27,6 +27,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@@ -44,11 +45,15 @@ interface GoogleMapsPrefs {
fun setSelectedCustomTileUrl(value: String?)
+ suspend fun awaitMapSelection(): GoogleMapSelectionPrefs
+
val cameraPosition: Flow<GoogleCameraPosition?>
fun setCameraPosition(value: GoogleCameraPosition)
}
+data class GoogleMapSelectionPrefs(val mapType: String, val customTileUrl: String?)
+
data class GoogleCameraPosition(
val targetLat: Double,
val targetLng: Double,
@@ -94,6 +99,13 @@ class GoogleMapsPrefsImpl(private val dataStore: GoogleMapsDataStore, dispatcher
}
}
+ override suspend fun awaitMapSelection(): GoogleMapSelectionPrefs = dataStore.data.first().let { preferences ->
+ GoogleMapSelectionPrefs(
+ mapType = preferences[KEY_SELECTED_GOOGLE_MAP_TYPE_PREF] ?: MapType.NORMAL.name,
+ customTileUrl = preferences[KEY_SELECTED_CUSTOM_TILE_URL_PREF],
+ )
+ }
+
override val cameraPosition: Flow<GoogleCameraPosition?> =
dataStore.data.map { preferences ->
val latitude = preferences.getCameraCoordinate(KEY_CAMERA_TARGET_LAT_PREF) ?: return@map null
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/repository/CustomTileProviderRepository.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/repository/CustomTileProviderRepository.kt
deleted file mode 100644
index 48d89d258c..0000000000
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/repository/CustomTileProviderRepository.kt
+++ /dev/null
@@ -1,104 +0,0 @@
-/*
- * Copyright (c) 2026 Meshtastic LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see <https://www.gnu.org/licenses/>.
- */
-package org.meshtastic.app.map.repository
-
-import co.touchlab.kermit.Logger
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.withContext
-import kotlinx.serialization.SerializationException
-import kotlinx.serialization.json.Json
-import org.koin.core.annotation.Single
-import org.meshtastic.app.map.model.CustomTileProviderConfig
-import org.meshtastic.core.di.CoroutineDispatchers
-import org.meshtastic.core.repository.MapTileProviderPrefs
-
-interface CustomTileProviderRepository {
- fun getCustomTileProviders(): Flow<List<CustomTileProviderConfig>>
-
- suspend fun addCustomTileProvider(config: CustomTileProviderConfig)
-
- suspend fun updateCustomTileProvider(config: CustomTileProviderConfig)
-
- suspend fun deleteCustomTileProvider(configId: String)
-
- suspend fun getCustomTileProviderById(configId: String): CustomTileProviderConfig?
-}
-
-@Single
-class CustomTileProviderRepositoryImpl(
- private val json: Json,
- private val dispatchers: CoroutineDispatchers,
- private val mapTileProviderPrefs: MapTileProviderPrefs,
-) : CustomTileProviderRepository {
-
- private val customTileProvidersStateFlow = MutableStateFlow<List<CustomTileProviderConfig>>(emptyList())
-
- init {
- loadDataFromPrefs()
- }
-
- override fun getCustomTileProviders(): Flow<List<CustomTileProviderConfig>> =
- customTileProvidersStateFlow.asStateFlow()
-
- override suspend fun addCustomTileProvider(config: CustomTileProviderConfig) {
- val newList = customTileProvidersStateFlow.value + config
- customTileProvidersStateFlow.value = newList
- saveDataToPrefs(newList)
- }
-
- override suspend fun updateCustomTileProvider(config: CustomTileProviderConfig) {
- val newList = customTileProvidersStateFlow.value.map { if (it.id == config.id) config else it }
- customTileProvidersStateFlow.value = newList
- saveDataToPrefs(newList)
- }
-
- override suspend fun deleteCustomTileProvider(configId: String) {
- val newList = customTileProvidersStateFlow.value.filterNot { it.id == configId }
- customTileProvidersStateFlow.value = newList
- saveDataToPrefs(newList)
- }
-
- override suspend fun getCustomTileProviderById(configId: String): CustomTileProviderConfig? =
- customTileProvidersStateFlow.value.find { it.id == configId }
-
- private fun loadDataFromPrefs() {
- val jsonString = mapTileProviderPrefs.customTileProviders.value
- if (jsonString != null) {
- try {
- customTileProvidersStateFlow.value = json.decodeFromString<List<CustomTileProviderConfig>>(jsonString)
- } catch (e: SerializationException) {
- Logger.e(e) { "Error deserializing tile providers" }
- customTileProvidersStateFlow.value = emptyList()
- }
- } else {
- customTileProvidersStateFlow.value = emptyList()
- }
- }
-
- private suspend fun saveDataToPrefs(providers: List<CustomTileProviderConfig>) {
- withContext(dispatchers.io) {
- try {
- val jsonString = json.encodeToString(providers)
- mapTileProviderPrefs.setCustomTileProviders(jsonString)
- } catch (e: SerializationException) {
- Logger.e(e) { "Error serializing tile providers" }
- }
- }
- }
-}
diff --git a/androidApp/src/main/kotlin/org/meshtastic/app/map/component/CustomTileProviderManager.kt b/androidApp/src/main/kotlin/org/meshtastic/app/map/component/CustomTileProviderManager.kt
new file mode 100644
index 0000000000..10dbebbe1e
--- /dev/null
+++ b/androidApp/src/main/kotlin/org/meshtastic/app/map/component/CustomTileProviderManager.kt
@@ -0,0 +1,258 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.app.map.component
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.Button
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.app.map.model.CustomTileProviderConfig
+import org.meshtastic.app.map.model.isValidTileUrlTemplate
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.add_custom_tile_source
+import org.meshtastic.core.resources.add_local_mbtiles_file
+import org.meshtastic.core.resources.cancel
+import org.meshtastic.core.resources.delete_custom_tile_source
+import org.meshtastic.core.resources.edit_custom_tile_source
+import org.meshtastic.core.resources.local_mbtiles_file
+import org.meshtastic.core.resources.manage_custom_tile_sources
+import org.meshtastic.core.resources.name
+import org.meshtastic.core.resources.name_cannot_be_empty
+import org.meshtastic.core.resources.no_custom_tile_sources_found
+import org.meshtastic.core.resources.provider_name_exists
+import org.meshtastic.core.resources.save
+import org.meshtastic.core.resources.url_cannot_be_empty
+import org.meshtastic.core.resources.url_must_contain_placeholders
+import org.meshtastic.core.resources.url_template
+import org.meshtastic.core.resources.url_template_hint
+import org.meshtastic.core.ui.component.MeshtasticDialog
+import org.meshtastic.core.ui.icon.Delete
+import org.meshtastic.core.ui.icon.Edit
+import org.meshtastic.core.ui.icon.MeshtasticIcons
+
+@Suppress("LongMethod", "LongParameterList")
+@Composable
+internal fun CustomTileProviderManager(
+ providers: List<CustomTileProviderConfig>,
+ onAdd: (CustomTileProviderConfig) -> Unit,
+ onUpdate: (CustomTileProviderConfig) -> Unit,
+ onDelete: (String) -> Unit,
+ onAddLocalMbTiles: (() -> Unit)? = null,
+) {
+ var editingConfig by remember { mutableStateOf<CustomTileProviderConfig?>(null) }
+ var showEditDialog by remember { mutableStateOf(false) }
+
+ if (showEditDialog) {
+ AddEditCustomTileProviderDialog(
+ config = editingConfig,
+ providers = providers,
+ onDismiss = { showEditDialog = false },
+ onSave = { config ->
+ if (editingConfig == null) onAdd(config) else onUpdate(config)
+ showEditDialog = false
+ },
+ )
+ }
+
+ LazyColumn(contentPadding = PaddingValues(bottom = 16.dp)) {
+ item {
+ Text(
+ text = stringResource(Res.string.manage_custom_tile_sources),
+ style = MaterialTheme.typography.headlineSmall,
+ modifier = Modifier.padding(16.dp),
+ )
+ HorizontalDivider()
+ }
+
+ if (providers.isEmpty()) {
+ item {
+ Text(
+ text = stringResource(Res.string.no_custom_tile_sources_found),
+ modifier = Modifier.padding(16.dp),
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ }
+ } else {
+ items(providers, key = { it.id }) { config ->
+ ListItem(
+ headlineContent = { Text(config.name) },
+ supportingContent = {
+ Text(
+ if (config.isLocal) {
+ stringResource(Res.string.local_mbtiles_file)
+ } else {
+ config.urlTemplate
+ },
+ style = MaterialTheme.typography.bodySmall,
+ )
+ },
+ trailingContent = {
+ Row {
+ if (!config.isLocal) {
+ IconButton(
+ onClick = {
+ editingConfig = config
+ showEditDialog = true
+ },
+ ) {
+ Icon(
+ MeshtasticIcons.Edit,
+ contentDescription = stringResource(Res.string.edit_custom_tile_source),
+ )
+ }
+ }
+ IconButton(onClick = { onDelete(config.id) }) {
+ Icon(
+ MeshtasticIcons.Delete,
+ contentDescription = stringResource(Res.string.delete_custom_tile_source),
+ )
+ }
+ }
+ },
+ )
+ HorizontalDivider()
+ }
+ }
+
+ item {
+ Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Button(
+ onClick = {
+ editingConfig = null
+ showEditDialog = true
+ },
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(stringResource(Res.string.add_custom_tile_source))
+ }
+ onAddLocalMbTiles?.let { onAddLocal ->
+ Button(onClick = onAddLocal, modifier = Modifier.fillMaxWidth()) {
+ Text(stringResource(Res.string.add_local_mbtiles_file))
+ }
+ }
+ }
+ }
+ }
+}
+
+@Suppress("LongMethod")
+@Composable
+private fun AddEditCustomTileProviderDialog(
+ config: CustomTileProviderConfig?,
+ providers: List<CustomTileProviderConfig>,
+ onDismiss: () -> Unit,
+ onSave: (CustomTileProviderConfig) -> Unit,
+) {
+ var name by rememberSaveable { mutableStateOf(config?.name ?: "") }
+ var url by rememberSaveable { mutableStateOf(config?.urlTemplate ?: "") }
+ var nameError by remember { mutableStateOf<String?>(null) }
+ var urlError by remember { mutableStateOf<String?>(null) }
+
+ val emptyNameError = stringResource(Res.string.name_cannot_be_empty)
+ val providerNameExistsError = stringResource(Res.string.provider_name_exists)
+ val urlCannotBeEmptyError = stringResource(Res.string.url_cannot_be_empty)
+ val urlMustContainPlaceholdersError = stringResource(Res.string.url_must_contain_placeholders)
+
+ fun validateAndSave() {
+ nameError = validateName(name, providers, config?.id, emptyNameError, providerNameExistsError)
+ urlError = validateUrl(url, urlCannotBeEmptyError, urlMustContainPlaceholdersError)
+ if (nameError == null && urlError == null) {
+ onSave(
+ (config ?: CustomTileProviderConfig(name = name, urlTemplate = url))
+ .copy(name = name, urlTemplate = url)
+ .normalized(),
+ )
+ }
+ }
+
+ MeshtasticDialog(
+ onDismiss = onDismiss,
+ title =
+ stringResource(
+ if (config == null) Res.string.add_custom_tile_source else Res.string.edit_custom_tile_source,
+ ),
+ text = {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ OutlinedTextField(
+ value = name,
+ onValueChange = {
+ name = it
+ nameError = null
+ },
+ label = { Text(stringResource(Res.string.name)) },
+ isError = nameError != null,
+ supportingText = { nameError?.let { Text(it) } },
+ singleLine = true,
+ )
+ OutlinedTextField(
+ value = url,
+ onValueChange = {
+ url = it
+ urlError = null
+ },
+ label = { Text(stringResource(Res.string.url_template)) },
+ isError = urlError != null,
+ supportingText = { Text(urlError ?: stringResource(Res.string.url_template_hint)) },
+ singleLine = false,
+ maxLines = 2,
+ )
+ }
+ },
+ onConfirm = ::validateAndSave,
+ confirmTextRes = Res.string.save,
+ dismissTextRes = Res.string.cancel,
+ )
+}
+
+private fun validateName(
+ name: String,
+ providers: List<CustomTileProviderConfig>,
+ currentId: String?,
+ emptyNameError: String,
+ nameExistsError: String,
+): String? = when {
+ name.isBlank() -> emptyNameError
+ providers.any { it.id != currentId && it.name.trim().equals(name.trim(), ignoreCase = true) } -> nameExistsError
+ else -> null
+}
+
+private fun validateUrl(url: String, emptyUrlError: String, missingPlaceholdersError: String): String? = when {
+ url.isBlank() -> emptyUrlError
+ !url.isValidTileUrlTemplate(requireHttps = false) -> missingPlaceholdersError
+ else -> null
+}
diff --git a/androidApp/src/main/kotlin/org/meshtastic/app/map/model/CustomTileProviderConfig.kt b/androidApp/src/main/kotlin/org/meshtastic/app/map/model/CustomTileProviderConfig.kt
new file mode 100644
index 0000000000..e5659f5cbe
--- /dev/null
+++ b/androidApp/src/main/kotlin/org/meshtastic/app/map/model/CustomTileProviderConfig.kt
@@ -0,0 +1,51 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.app.map.model
+
+import kotlinx.serialization.Serializable
+import java.net.URI
+import kotlin.uuid.Uuid
+
+@Serializable
+data class CustomTileProviderConfig(
+ val id: String = Uuid.random().toString(),
+ val name: String,
+ val urlTemplate: String,
+ val localUri: String? = null,
+) {
+ val isLocal: Boolean
+ get() = localUri != null
+
+ fun normalized(): CustomTileProviderConfig = copy(name = name.trim(), urlTemplate = urlTemplate.trim())
+}
+
+internal fun String.isValidTileUrlTemplate(requireHttps: Boolean): Boolean {
+ val hasPlaceholders =
+ contains("{z}", ignoreCase = true) && contains("{x}", ignoreCase = true) && contains("{y}", ignoreCase = true)
+ val normalizedForParsing =
+ replace("{s}", "a", ignoreCase = true)
+ .replace("{z}", "0", ignoreCase = true)
+ .replace("{x}", "0", ignoreCase = true)
+ .replace("{y}", "0", ignoreCase = true)
+ val parsed = runCatching { URI(normalizedForParsing) }.getOrNull() ?: return false
+ val scheme = parsed.scheme?.lowercase()
+ val hasValidAuthority = !parsed.host.isNullOrBlank() && parsed.rawUserInfo == null
+ val hasValidScheme = if (requireHttps) scheme == "https" else scheme == "http" || scheme == "https"
+ // A private/link-local host blocklist is intentionally omitted: the user supplies the tile endpoint, requests carry
+ // no Meshtastic/server-held credentials, and client-side tile GETs make that SSRF shape an accepted low-risk case.
+ return hasPlaceholders && parsed.rawFragment == null && hasValidAuthority && hasValidScheme
+}
diff --git a/androidApp/src/main/kotlin/org/meshtastic/app/map/repository/CustomTileProviderRepository.kt b/androidApp/src/main/kotlin/org/meshtastic/app/map/repository/CustomTileProviderRepository.kt
new file mode 100644
index 0000000000..cc3923c705
--- /dev/null
+++ b/androidApp/src/main/kotlin/org/meshtastic/app/map/repository/CustomTileProviderRepository.kt
@@ -0,0 +1,168 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.app.map.repository
+
+import co.touchlab.kermit.Logger
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.serialization.SerializationException
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.jsonArray
+import kotlinx.serialization.json.jsonObject
+import org.koin.core.annotation.Single
+import org.meshtastic.app.map.model.CustomTileProviderConfig
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.repository.MapTileProviderPrefs
+
+interface CustomTileProviderRepository {
+ fun getCustomTileProviders(): Flow<List<CustomTileProviderConfig>>
+
+ suspend fun awaitCustomTileProviders(): CustomTileProviderLoadResult
+
+ suspend fun addCustomTileProvider(config: CustomTileProviderConfig): CustomTileProviderSaveResult
+
+ suspend fun updateCustomTileProvider(config: CustomTileProviderConfig): CustomTileProviderSaveResult
+
+ suspend fun deleteCustomTileProvider(configId: String)
+
+ suspend fun getCustomTileProviderById(configId: String): CustomTileProviderConfig?
+}
+
+data class CustomTileProviderLoadResult(val providers: List<CustomTileProviderConfig>, val isSuccessful: Boolean)
+
+enum class CustomTileProviderSaveResult {
+ SAVED,
+ DUPLICATE_NAME,
+ NOT_FOUND,
+}
+
+@Single
+class CustomTileProviderRepositoryImpl(
+ private val json: Json,
+ dispatchers: CoroutineDispatchers,
+ private val mapTileProviderPrefs: MapTileProviderPrefs,
+) : CustomTileProviderRepository {
+ private val scope = CoroutineScope(SupervisorJob() + dispatchers.default)
+ private val mutex = Mutex()
+ private var isLoaded = false
+ private var initialLoadSuccessful = true
+ private val providers = MutableStateFlow<List<CustomTileProviderConfig>>(emptyList())
+
+ init {
+ scope.launch { mutex.withLock { loadIfNeeded() } }
+ }
+
+ override fun getCustomTileProviders(): Flow<List<CustomTileProviderConfig>> = providers.asStateFlow()
+
+ override suspend fun awaitCustomTileProviders(): CustomTileProviderLoadResult = mutex.withLock {
+ loadIfNeeded()
+ CustomTileProviderLoadResult(providers = providers.value, isSuccessful = initialLoadSuccessful)
+ }
+
+ override suspend fun addCustomTileProvider(config: CustomTileProviderConfig): CustomTileProviderSaveResult =
+ mutex.withLock {
+ loadIfNeeded()
+ val normalized = config.normalized()
+ if (providers.value.any { it.name.trim().equals(normalized.name, ignoreCase = true) }) {
+ return@withLock CustomTileProviderSaveResult.DUPLICATE_NAME
+ }
+ persist(providers.value + normalized)
+ CustomTileProviderSaveResult.SAVED
+ }
+
+ override suspend fun updateCustomTileProvider(config: CustomTileProviderConfig): CustomTileProviderSaveResult =
+ mutex.withLock {
+ loadIfNeeded()
+ val normalized = config.normalized()
+ if (providers.value.none { it.id == normalized.id }) {
+ return@withLock CustomTileProviderSaveResult.NOT_FOUND
+ }
+ if (
+ providers.value.any {
+ it.id != normalized.id && it.name.trim().equals(normalized.name, ignoreCase = true)
+ }
+ ) {
+ return@withLock CustomTileProviderSaveResult.DUPLICATE_NAME
+ }
+ persist(providers.value.map { if (it.id == normalized.id) normalized else it })
+ CustomTileProviderSaveResult.SAVED
+ }
+
+ override suspend fun deleteCustomTileProvider(configId: String) {
+ mutate { current -> current.filterNot { it.id == configId } }
+ }
+
+ override suspend fun getCustomTileProviderById(configId: String): CustomTileProviderConfig? = mutex.withLock {
+ loadIfNeeded()
+ providers.value.find { it.id == configId }
+ }
+
+ private suspend fun mutate(transform: (List<CustomTileProviderConfig>) -> List<CustomTileProviderConfig>) {
+ mutex.withLock {
+ loadIfNeeded()
+ persist(transform(providers.value))
+ }
+ }
+
+ private suspend fun persist(updated: List<CustomTileProviderConfig>) {
+ mapTileProviderPrefs.setCustomTileProviders(json.encodeToString(updated))
+ providers.value = updated
+ }
+
+ private suspend fun loadIfNeeded() {
+ if (isLoaded) return
+ val result = decode(mapTileProviderPrefs.awaitCustomTileProviders())
+ if (result.mustPersistGeneratedIds) {
+ persist(result.providers)
+ } else {
+ providers.value = result.providers
+ }
+ initialLoadSuccessful = result.isSuccessful
+ isLoaded = true
+ }
+
+ private fun decode(serialized: String?): DecodedCustomTileProviders = if (serialized.isNullOrBlank()) {
+ DecodedCustomTileProviders(providers = emptyList(), isSuccessful = true)
+ } else {
+ try {
+ DecodedCustomTileProviders(
+ providers = json.decodeFromString(serialized),
+ isSuccessful = true,
+ mustPersistGeneratedIds =
+ json.parseToJsonElement(serialized).jsonArray.any { "id" !in it.jsonObject },
+ )
+ } catch (_: SerializationException) {
+ Logger.w { "Ignoring malformed persisted tile providers" }
+ DecodedCustomTileProviders(providers = emptyList(), isSuccessful = false)
+ } catch (_: IllegalArgumentException) {
+ Logger.w { "Ignoring malformed persisted tile providers" }
+ DecodedCustomTileProviders(providers = emptyList(), isSuccessful = false)
+ }
+ }
+}
+
+private data class DecodedCustomTileProviders(
+ val providers: List<CustomTileProviderConfig>,
+ val isSuccessful: Boolean,
+ val mustPersistGeneratedIds: Boolean = false,
+)
diff --git a/androidApp/src/test/kotlin/org/meshtastic/app/map/model/CustomTileProviderConfigTest.kt b/androidApp/src/test/kotlin/org/meshtastic/app/map/model/CustomTileProviderConfigTest.kt
new file mode 100644
index 0000000000..452cfb9f92
--- /dev/null
+++ b/androidApp/src/test/kotlin/org/meshtastic/app/map/model/CustomTileProviderConfigTest.kt
@@ -0,0 +1,39 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.app.map.model
+
+import kotlin.test.Test
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class CustomTileProviderConfigTest {
+ @Test
+ fun `Google-compatible validation retains HTTP support`() {
+ assertTrue("http://tiles.example.org/{z}/{x}/{y}.png".isValidTileUrlTemplate(requireHttps = false))
+ assertTrue("https://{s}.example.org/{Z}/{X}/{Y}.jpg".isValidTileUrlTemplate(requireHttps = false))
+ }
+
+ @Test
+ fun `Google-compatible validation rejects unsafe and unresolved templates`() {
+ assertFalse("http://token@tiles.example.org/{z}/{x}/{y}.png".isValidTileUrlTemplate(requireHttps = false))
+ assertFalse("http:///tiles/{z}/{x}/{y}.png".isValidTileUrlTemplate(requireHttps = false))
+ assertFalse("http://tiles.example.org/static#{z}/{x}/{y}".isValidTileUrlTemplate(requireHttps = false))
+ assertFalse(
+ "http://tiles.example.org/{z}/{x}/{y}.png?token={apiKey}".isValidTileUrlTemplate(requireHttps = false),
+ )
+ }
+}
diff --git a/androidApp/src/test/kotlin/org/meshtastic/app/map/repository/CustomTileProviderRepositoryTest.kt b/androidApp/src/test/kotlin/org/meshtastic/app/map/repository/CustomTileProviderRepositoryTest.kt
new file mode 100644
index 0000000000..23600c6a7f
--- /dev/null
+++ b/androidApp/src/test/kotlin/org/meshtastic/app/map/repository/CustomTileProviderRepositoryTest.kt
@@ -0,0 +1,178 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.app.map.repository
+
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+import kotlinx.serialization.json.Json
+import org.meshtastic.app.map.model.CustomTileProviderConfig
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.testing.FakeMapTileProviderPrefs
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class CustomTileProviderRepositoryTest {
+ @Test
+ fun `stored providers load before first mutation and survive recreation`() = runTest {
+ val dispatcher = StandardTestDispatcher(testScheduler)
+ val dispatchers = CoroutineDispatchers(dispatcher, dispatcher, dispatcher)
+ val prefs = FakeMapTileProviderPrefs()
+ val json = Json { ignoreUnknownKeys = true }
+ val existing = provider(id = "existing", name = "Existing")
+ prefs.customTileProviders.value = json.encodeToString(listOf(existing))
+ val repository = CustomTileProviderRepositoryImpl(json, dispatchers, prefs)
+
+ assertEquals(
+ CustomTileProviderSaveResult.SAVED,
+ repository.addCustomTileProvider(provider(id = "new", name = "New")),
+ )
+ advanceUntilIdle()
+
+ val stored = json.decodeFromString<List<CustomTileProviderConfig>>(prefs.customTileProviders.value!!)
+ assertEquals(listOf("existing", "new"), stored.map { it.id })
+ val recreated = CustomTileProviderRepositoryImpl(json, dispatchers, prefs)
+ val reloaded = recreated.awaitCustomTileProviders()
+ assertTrue(reloaded.isSuccessful)
+ assertEquals(listOf("existing", "new"), reloaded.providers.map { it.id })
+ advanceUntilIdle()
+ assertEquals(listOf("existing", "new"), recreated.getCustomTileProviders().first().map { it.id })
+ }
+
+ @Test
+ fun `duplicate name is rejected atomically before cold load completes`() = runTest {
+ val dispatcher = StandardTestDispatcher(testScheduler)
+ val dispatchers = CoroutineDispatchers(dispatcher, dispatcher, dispatcher)
+ val prefs = FakeMapTileProviderPrefs()
+ val json = Json { ignoreUnknownKeys = true }
+ val existing = provider(id = "existing", name = "Existing")
+ prefs.customTileProviders.value = json.encodeToString(listOf(existing))
+ val repository = CustomTileProviderRepositoryImpl(json, dispatchers, prefs)
+
+ val result = repository.addCustomTileProvider(provider(id = "duplicate", name = " eXiStInG "))
+
+ assertEquals(CustomTileProviderSaveResult.DUPLICATE_NAME, result)
+ assertEquals(
+ listOf(existing),
+ json.decodeFromString<List<CustomTileProviderConfig>>(prefs.customTileProviders.value!!),
+ )
+ }
+
+ @Test
+ fun `update cannot claim another provider name`() = runTest {
+ val dispatcher = StandardTestDispatcher(testScheduler)
+ val dispatchers = CoroutineDispatchers(dispatcher, dispatcher, dispatcher)
+ val prefs = FakeMapTileProviderPrefs()
+ val json = Json { ignoreUnknownKeys = true }
+ val existing = provider(id = "existing", name = "Existing")
+ val other = provider(id = "other", name = "Other")
+ prefs.customTileProviders.value = json.encodeToString(listOf(existing, other))
+ val repository = CustomTileProviderRepositoryImpl(json, dispatchers, prefs)
+
+ val result = repository.updateCustomTileProvider(other.copy(name = "EXISTING"))
+
+ assertEquals(CustomTileProviderSaveResult.DUPLICATE_NAME, result)
+ assertEquals(
+ listOf(existing, other),
+ json.decodeFromString<List<CustomTileProviderConfig>>(prefs.customTileProviders.value!!),
+ )
+ }
+
+ @Test
+ fun `successful update normalizes and persists the provider`() = runTest {
+ val dispatcher = StandardTestDispatcher(testScheduler)
+ val dispatchers = CoroutineDispatchers(dispatcher, dispatcher, dispatcher)
+ val prefs = FakeMapTileProviderPrefs()
+ val json = Json { ignoreUnknownKeys = true }
+ val existing = provider(id = "existing", name = "Existing")
+ prefs.customTileProviders.value = json.encodeToString(listOf(existing))
+ val repository = CustomTileProviderRepositoryImpl(json, dispatchers, prefs)
+
+ val result =
+ repository.updateCustomTileProvider(
+ existing.copy(name = " Updated ", urlTemplate = " https://updated.example.org/{z}/{x}/{y}.png "),
+ )
+
+ assertEquals(CustomTileProviderSaveResult.SAVED, result)
+ assertEquals(
+ listOf(existing.copy(name = "Updated", urlTemplate = "https://updated.example.org/{z}/{x}/{y}.png")),
+ json.decodeFromString<List<CustomTileProviderConfig>>(prefs.customTileProviders.value!!),
+ )
+ }
+
+ @Test
+ fun `missing update leaves persisted providers unchanged`() = runTest {
+ val dispatcher = StandardTestDispatcher(testScheduler)
+ val dispatchers = CoroutineDispatchers(dispatcher, dispatcher, dispatcher)
+ val prefs = FakeMapTileProviderPrefs()
+ val json = Json { ignoreUnknownKeys = true }
+ val existing = provider(id = "existing", name = "Existing")
+ prefs.customTileProviders.value = json.encodeToString(listOf(existing))
+ val repository = CustomTileProviderRepositoryImpl(json, dispatchers, prefs)
+
+ val result = repository.updateCustomTileProvider(provider(id = "missing", name = "Missing"))
+
+ assertEquals(CustomTileProviderSaveResult.NOT_FOUND, result)
+ assertEquals(
+ listOf(existing),
+ json.decodeFromString<List<CustomTileProviderConfig>>(prefs.customTileProviders.value!!),
+ )
+ }
+
+ @Test
+ fun `delete persists the remaining providers`() = runTest {
+ val dispatcher = StandardTestDispatcher(testScheduler)
+ val dispatchers = CoroutineDispatchers(dispatcher, dispatcher, dispatcher)
+ val prefs = FakeMapTileProviderPrefs()
+ val json = Json { ignoreUnknownKeys = true }
+ val first = provider(id = "first", name = "First")
+ val second = provider(id = "second", name = "Second")
+ prefs.customTileProviders.value = json.encodeToString(listOf(first, second))
+ val repository = CustomTileProviderRepositoryImpl(json, dispatchers, prefs)
+
+ repository.deleteCustomTileProvider(first.id)
+
+ assertEquals(
+ listOf(second),
+ json.decodeFromString<List<CustomTileProviderConfig>>(prefs.customTileProviders.value!!),
+ )
+ }
+
+ @Test
+ fun `malformed persisted providers report a failed load without rewriting storage`() = runTest {
+ val dispatcher = StandardTestDispatcher(testScheduler)
+ val dispatchers = CoroutineDispatchers(dispatcher, dispatcher, dispatcher)
+ val prefs = FakeMapTileProviderPrefs()
+ val malformed = "[{not-json}]"
+ prefs.customTileProviders.value = malformed
+ val repository = CustomTileProviderRepositoryImpl(Json { ignoreUnknownKeys = true }, dispatchers, prefs)
+
+ val result = repository.awaitCustomTileProviders()
+
+ assertFalse(result.isSuccessful)
+ assertEquals(emptyList(), result.providers)
+ assertEquals(malformed, prefs.customTileProviders.value)
+ }
+
+ private fun provider(id: String, name: String) =
+ CustomTileProviderConfig(id = id, name = name, urlTemplate = "https://tiles.example.org/{z}/{x}/{y}.png")
+}
diff --git a/androidApp/src/testGoogle/kotlin/org/meshtastic/app/map/GoogleCustomTileSelectionTest.kt b/androidApp/src/testGoogle/kotlin/org/meshtastic/app/map/GoogleCustomTileSelectionTest.kt
new file mode 100644
index 0000000000..8115c11e61
--- /dev/null
+++ b/androidApp/src/testGoogle/kotlin/org/meshtastic/app/map/GoogleCustomTileSelectionTest.kt
@@ -0,0 +1,307 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.app.map
+
+import android.app.Application
+import androidx.lifecycle.SavedStateHandle
+import androidx.test.core.app.ApplicationProvider
+import com.google.maps.android.compose.MapType
+import dev.mokkery.MockMode
+import dev.mokkery.answering.returns
+import dev.mokkery.every
+import dev.mokkery.mock
+import io.ktor.client.HttpClient
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import kotlinx.serialization.json.Json
+import org.junit.runner.RunWith
+import org.meshtastic.app.map.model.CustomTileProviderConfig
+import org.meshtastic.app.map.prefs.map.GoogleCameraPosition
+import org.meshtastic.app.map.prefs.map.GoogleMapSelectionPrefs
+import org.meshtastic.app.map.prefs.map.GoogleMapsPrefs
+import org.meshtastic.app.map.repository.CustomTileProviderRepository
+import org.meshtastic.app.map.repository.CustomTileProviderRepositoryImpl
+import org.meshtastic.app.map.repository.CustomTileProviderSaveResult
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.repository.PacketRepository
+import org.meshtastic.core.testing.FakeMapPrefs
+import org.meshtastic.core.testing.FakeMapTileProviderPrefs
+import org.meshtastic.core.testing.FakeNodeRepository
+import org.meshtastic.core.testing.FakeNotificationPrefs
+import org.meshtastic.core.testing.FakeRadioConfigRepository
+import org.meshtastic.core.testing.FakeRadioController
+import org.meshtastic.core.testing.FakeUiPrefs
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNotEquals
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [35], application = Application::class)
+class GoogleCustomTileSelectionTest {
+ private val duplicateUrl = "https://tiles.example.org/{z}/{x}/{y}.png"
+ private val first = CustomTileProviderConfig(id = "first", name = "First", urlTemplate = duplicateUrl)
+ private val second = CustomTileProviderConfig(id = "second", name = "Second", urlTemplate = duplicateUrl)
+
+ @Test
+ fun `provider id disambiguates duplicate URLs across repository update delete and reload`() = runTest {
+ val dispatcher = StandardTestDispatcher(testScheduler)
+ val prefs = FakeMapTileProviderPrefs()
+ val json = Json { ignoreUnknownKeys = true }
+ prefs.customTileProviders.value = json.encodeToString(listOf(first, second))
+ var repository = repository(json, dispatcher, prefs)
+
+ assertEquals(second, repository.awaitCustomTileProviders().providers.findSelectedCustomTileProvider(second.id))
+
+ val updatedSecond = second.copy(name = "Updated")
+ assertEquals(CustomTileProviderSaveResult.SAVED, repository.updateCustomTileProvider(updatedSecond))
+
+ repository = repository(json, dispatcher, prefs)
+ val afterUpdate = repository.awaitCustomTileProviders().providers
+ assertEquals(updatedSecond, afterUpdate.findSelectedCustomTileProvider(second.id))
+
+ repository.deleteCustomTileProvider(first.id)
+ repository = repository(json, dispatcher, prefs)
+ val afterDeletingTwin = repository.awaitCustomTileProviders().providers
+ assertEquals(updatedSecond, afterDeletingTwin.findSelectedCustomTileProvider(second.id))
+
+ repository.deleteCustomTileProvider(second.id)
+ repository = repository(json, dispatcher, prefs)
+ assertNull(repository.awaitCustomTileProviders().providers.findSelectedCustomTileProvider(second.id))
+ }
+
+ @Test
+ fun `legacy URL migration deterministically selects first matching provider`() {
+ assertEquals(first, listOf(first, second).findLegacyCustomTileProvider(duplicateUrl))
+ }
+
+ @Test
+ fun `local provider accepts blank URL while remote provider does not`() {
+ val local = CustomTileProviderConfig(id = "local", name = "Local", urlTemplate = "", localUri = "file.mbtiles")
+ val remote = CustomTileProviderConfig(id = "remote", name = "Remote", urlTemplate = "")
+
+ assertTrue(local.hasValidGoogleTileSource())
+ assertFalse(remote.hasValidGoogleTileSource())
+ }
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ @Test
+ fun `legacy generated provider id survives a cold reload after URL migration`() = runTest {
+ val dispatcher = StandardTestDispatcher(testScheduler)
+ val prefs = FakeMapTileProviderPrefs()
+ val googleMapsPrefs = FakeGoogleMapsPrefs(customTileUrl = duplicateUrl)
+ val json = Json { ignoreUnknownKeys = true }
+ val legacyJson = """[{"name":"Legacy","urlTemplate":"$duplicateUrl"}]"""
+ prefs.customTileProviders.value = legacyJson
+
+ Dispatchers.setMain(dispatcher)
+ var httpClient: HttpClient? = null
+ try {
+ val application = ApplicationProvider.getApplicationContext<Application>()
+ val mapPrefs = FakeMapPrefs()
+ val client = HttpClient().also { httpClient = it }
+ val mapLayersManager =
+ MapLayersManager(
+ application = application,
+ dispatchers = CoroutineDispatchers(dispatcher, dispatcher, dispatcher),
+ httpClient = client,
+ mapPrefs = mapPrefs,
+ )
+
+ assertNull(prefs.selectedCustomTileProviderId.value)
+ assertEquals(duplicateUrl, googleMapsPrefs.selectedCustomTileUrl.value)
+
+ val firstViewModel =
+ mapViewModel(
+ dispatcher = dispatcher,
+ application = application,
+ mapLayersManager = mapLayersManager,
+ mapPrefs = mapPrefs,
+ repository = repository(json, dispatcher, prefs),
+ mapTileProviderPrefs = prefs,
+ googleMapsPrefs = googleMapsPrefs,
+ )
+ advanceUntilIdle()
+
+ val persistedAfterMigration = prefs.customTileProviders.value!!
+ val persistedProvider =
+ json.decodeFromString<List<CustomTileProviderConfig>>(persistedAfterMigration).single()
+
+ assertNotEquals(legacyJson, persistedAfterMigration)
+ assertEquals(persistedProvider.id, prefs.selectedCustomTileProviderId.value)
+ assertEquals(persistedProvider.id, firstViewModel.selectedCustomTileProviderId.value)
+ assertNull(googleMapsPrefs.selectedCustomTileUrl.value)
+
+ val coldViewModel =
+ mapViewModel(
+ dispatcher = dispatcher,
+ application = application,
+ mapLayersManager = mapLayersManager,
+ mapPrefs = mapPrefs,
+ repository = repository(json, dispatcher, prefs),
+ mapTileProviderPrefs = prefs,
+ googleMapsPrefs = googleMapsPrefs,
+ )
+ advanceUntilIdle()
+
+ assertEquals(persistedProvider.id, coldViewModel.selectedCustomTileProviderId.value)
+ assertEquals(persistedProvider.id, prefs.selectedCustomTileProviderId.value)
+ } finally {
+ try {
+ httpClient?.close()
+ } finally {
+ Dispatchers.resetMain()
+ }
+ }
+ }
+
+ private fun mapViewModel(
+ dispatcher: CoroutineDispatcher,
+ application: Application,
+ mapLayersManager: MapLayersManager,
+ mapPrefs: FakeMapPrefs,
+ repository: CustomTileProviderRepository,
+ mapTileProviderPrefs: FakeMapTileProviderPrefs,
+ googleMapsPrefs: GoogleMapsPrefs,
+ ): MapViewModel {
+ val packetRepository = mock<PacketRepository>(MockMode.autofill)
+ every { packetRepository.getWaypoints() } returns flowOf(emptyList())
+
+ return MapViewModel(
+ application = application,
+ dispatchers = CoroutineDispatchers(dispatcher, dispatcher, dispatcher),
+ mapLayersManager = mapLayersManager,
+ mapPrefs = mapPrefs,
+ googleMapsPrefs = googleMapsPrefs,
+ nodeRepository = FakeNodeRepository(),
+ packetRepository = packetRepository,
+ radioConfigRepository = FakeRadioConfigRepository(),
+ radioController = FakeRadioController(),
+ customTileProviderRepository = repository,
+ mapTileProviderPrefs = mapTileProviderPrefs,
+ uiPrefs = FakeUiPrefs(),
+ notificationPrefs = FakeNotificationPrefs(),
+ savedStateHandle = SavedStateHandle(),
+ )
+ }
+
+ private class FakeGoogleMapsPrefs(customTileUrl: String?) : GoogleMapsPrefs {
+ override val selectedGoogleMapType = MutableStateFlow<String?>(MapType.NORMAL.name)
+ override val selectedCustomTileUrl = MutableStateFlow(customTileUrl)
+ override val cameraPosition = MutableStateFlow<GoogleCameraPosition?>(null)
+
+ override fun setSelectedGoogleMapType(value: String?) {
+ selectedGoogleMapType.value = value
+ }
+
+ override fun setSelectedCustomTileUrl(value: String?) {
+ selectedCustomTileUrl.value = value
+ }
+
+ override suspend fun awaitMapSelection() = GoogleMapSelectionPrefs(
+ mapType = selectedGoogleMapType.value ?: MapType.NORMAL.name,
+ customTileUrl = selectedCustomTileUrl.value,
+ )
+
+ override fun setCameraPosition(value: GoogleCameraPosition) {
+ cameraPosition.value = value
+ }
+ }
+
+ @Test
+ fun `provider id takes precedence over a different legacy URL match`() {
+ val selectedById =
+ CustomTileProviderConfig(
+ id = "selected",
+ name = "Selected",
+ urlTemplate = "https://selected.example.org/{z}/{x}/{y}.png",
+ )
+
+ val resolved =
+ listOf(first, selectedById)
+ .resolvePersistedCustomTileSelection(
+ selectedProviderId = selectedById.id,
+ legacySource = duplicateUrl,
+ providerLoadSuccessful = true,
+ )
+
+ assertEquals(selectedById, resolved.provider)
+ }
+
+ @Test
+ fun `provider without a valid URL template is not selected`() {
+ val invalid =
+ CustomTileProviderConfig(
+ id = "invalid",
+ name = "Invalid",
+ urlTemplate = "https://tiles.example.org/static.png",
+ )
+
+ val resolved =
+ listOf(invalid)
+ .resolvePersistedCustomTileSelection(
+ selectedProviderId = invalid.id,
+ legacySource = null,
+ providerLoadSuccessful = true,
+ )
+
+ assertNull(resolved.provider)
+ assertTrue(resolved.canDiscardMissingSelection)
+ }
+
+ @Test
+ fun `missing persisted selection is cleared only after providers load successfully`() {
+ val failedLoad =
+ emptyList<CustomTileProviderConfig>()
+ .resolvePersistedCustomTileSelection(
+ selectedProviderId = "selected",
+ legacySource = duplicateUrl,
+ providerLoadSuccessful = false,
+ )
+ val successfulLoad =
+ emptyList<CustomTileProviderConfig>()
+ .resolvePersistedCustomTileSelection(
+ selectedProviderId = "selected",
+ legacySource = duplicateUrl,
+ providerLoadSuccessful = true,
+ )
+
+ assertNull(failedLoad.provider)
+ assertFalse(failedLoad.canDiscardMissingSelection)
+ assertNull(successfulLoad.provider)
+ assertTrue(successfulLoad.canDiscardMissingSelection)
+ }
+
+ private fun repository(json: Json, dispatcher: CoroutineDispatcher, prefs: FakeMapTileProviderPrefs) =
+ CustomTileProviderRepositoryImpl(
+ json = json,
+ dispatchers = CoroutineDispatchers(dispatcher, dispatcher, dispatcher),
+ mapTileProviderPrefs = prefs,
+ )
+}
diff --git a/androidApp/src/testGoogle/kotlin/org/meshtastic/app/map/MapViewModelSitePlannerRequestTest.kt b/androidApp/src/testGoogle/kotlin/org/meshtastic/app/map/MapViewModelSitePlannerRequestTest.kt
index 1e3c4776cc..09716fcc12 100644
--- a/androidApp/src/testGoogle/kotlin/org/meshtastic/app/map/MapViewModelSitePlannerRequestTest.kt
+++ b/androidApp/src/testGoogle/kotlin/org/meshtastic/app/map/MapViewModelSitePlannerRequestTest.kt
@@ -20,9 +20,11 @@ import android.app.Application
import androidx.lifecycle.SavedStateHandle
import androidx.test.core.app.ApplicationProvider
import app.cash.turbine.test
+import com.google.maps.android.compose.MapType
import dev.mokkery.MockMode
import dev.mokkery.answering.returns
import dev.mokkery.every
+import dev.mokkery.everySuspend
import dev.mokkery.mock
import io.ktor.client.HttpClient
import kotlinx.coroutines.Dispatchers
@@ -40,12 +42,15 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.meshtastic.app.map.model.CustomTileProviderConfig
import org.meshtastic.app.map.prefs.map.GoogleCameraPosition
+import org.meshtastic.app.map.prefs.map.GoogleMapSelectionPrefs
import org.meshtastic.app.map.prefs.map.GoogleMapsPrefs
+import org.meshtastic.app.map.repository.CustomTileProviderLoadResult
import org.meshtastic.app.map.repository.CustomTileProviderRepository
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.Node
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.testing.FakeMapPrefs
+import org.meshtastic.core.testing.FakeMapTileProviderPrefs
import org.meshtastic.core.testing.FakeNodeRepository
import org.meshtastic.core.testing.FakeNotificationPrefs
import org.meshtastic.core.testing.FakeRadioConfigRepository
@@ -88,8 +93,13 @@ class MapViewModelSitePlannerRequestTest {
every { googleMapsPrefs.cameraPosition } returns flowOf<GoogleCameraPosition?>(null)
every { googleMapsPrefs.selectedCustomTileUrl } returns MutableStateFlow(null)
every { googleMapsPrefs.selectedGoogleMapType } returns MutableStateFlow(null)
+ everySuspend { googleMapsPrefs.awaitMapSelection() } returns
+ GoogleMapSelectionPrefs(mapType = MapType.NORMAL.name, customTileUrl = null)
every { customTileProviderRepository.getCustomTileProviders() } returns
flowOf<List<CustomTileProviderConfig>>(emptyList())
+ // autofill cannot synthesize the load result, and the ViewModel init dereferences it.
+ everySuspend { customTileProviderRepository.awaitCustomTileProviders() } returns
+ CustomTileProviderLoadResult(providers = emptyList(), isSuccessful = true)
nodeRepository.setNodes(listOf(firstNode, secondNode))
viewModel =
@@ -104,6 +114,7 @@ class MapViewModelSitePlannerRequestTest {
radioConfigRepository = FakeRadioConfigRepository(),
radioController = FakeRadioController(),
customTileProviderRepository = customTileProviderRepository,
+ mapTileProviderPrefs = FakeMapTileProviderPrefs(),
uiPrefs = FakeUiPrefs(),
notificationPrefs = FakeNotificationPrefs(),
savedStateHandle = SavedStateHandle(),
diff --git a/androidApp/src/testGoogle/kotlin/org/meshtastic/app/map/prefs/map/GoogleMapsPrefsTest.kt b/androidApp/src/testGoogle/kotlin/org/meshtastic/app/map/prefs/map/GoogleMapsPrefsTest.kt
index c2bd72e30c..b5a6de32a9 100644
--- a/androidApp/src/testGoogle/kotlin/org/meshtastic/app/map/prefs/map/GoogleMapsPrefsTest.kt
+++ b/androidApp/src/testGoogle/kotlin/org/meshtastic/app/map/prefs/map/GoogleMapsPrefsTest.kt
@@ -77,4 +77,17 @@ class GoogleMapsPrefsTest {
assertEquals(cameraPosition, prefs.cameraPosition.first())
}
+
+ @Test
+ fun `selected custom tile URL is available from a load-aware snapshot`() = testScope.runTest {
+ prefs.setSelectedCustomTileUrl("https://tiles.example.org/{z}/{x}/{y}.png")
+
+ assertEquals(
+ GoogleMapSelectionPrefs(
+ mapType = "NORMAL",
+ customTileUrl = "https://tiles.example.org/{z}/{x}/{y}.png",
+ ),
+ prefs.awaitMapSelection(),
+ )
+ }
}
diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapTileProviderPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapTileProviderPrefsImpl.kt
index fb5c2edf03..1787ffc618 100644
--- a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapTileProviderPrefsImpl.kt
+++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapTileProviderPrefsImpl.kt
@@ -22,9 +22,9 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
-import kotlinx.coroutines.launch
import org.koin.core.annotation.Single
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.prefs.di.MapTileProviderDataStore
@@ -38,20 +38,38 @@ class MapTileProviderPrefsImpl(private val dataStore: MapTileProviderDataStore,
override val customTileProviders: StateFlow<String?> =
dataStore.data.map { it[KEY_CUSTOM_PROVIDERS_PREF] }.stateIn(scope, SharingStarted.Eagerly, null)
- override fun setCustomTileProviders(providers: String?) {
- scope.launch {
- dataStore.edit { prefs ->
- if (providers == null) {
- prefs.remove(KEY_CUSTOM_PROVIDERS_PREF)
- } else {
- prefs[KEY_CUSTOM_PROVIDERS_PREF] = providers
- }
+ override val selectedCustomTileProviderId: StateFlow<String?> =
+ dataStore.data.map { it[KEY_SELECTED_CUSTOM_PROVIDER_ID_PREF] }.stateIn(scope, SharingStarted.Eagerly, null)
+
+ override suspend fun awaitCustomTileProviders(): String? = dataStore.data.first()[KEY_CUSTOM_PROVIDERS_PREF]
+
+ override suspend fun awaitSelectedCustomTileProviderId(): String? =
+ dataStore.data.first()[KEY_SELECTED_CUSTOM_PROVIDER_ID_PREF]
+
+ override suspend fun setCustomTileProviders(providers: String?) {
+ dataStore.edit { prefs ->
+ if (providers == null) {
+ prefs.remove(KEY_CUSTOM_PROVIDERS_PREF)
+ } else {
+ prefs[KEY_CUSTOM_PROVIDERS_PREF] = providers
+ }
+ }
+ }
+
+ override suspend fun setSelectedCustomTileProviderId(providerId: String?) {
+ dataStore.edit { prefs ->
+ if (providerId == null) {
+ prefs.remove(KEY_SELECTED_CUSTOM_PROVIDER_ID_PREF)
+ } else {
+ prefs[KEY_SELECTED_CUSTOM_PROVIDER_ID_PREF] = providerId
}
}
}
companion object {
const val KEY_CUSTOM_PROVIDERS = "custom_tile_providers"
+ const val KEY_SELECTED_CUSTOM_PROVIDER_ID = "selected_custom_tile_provider_id"
val KEY_CUSTOM_PROVIDERS_PREF = stringPreferencesKey(KEY_CUSTOM_PROVIDERS)
+ val KEY_SELECTED_CUSTOM_PROVIDER_ID_PREF = stringPreferencesKey(KEY_SELECTED_CUSTOM_PROVIDER_ID)
}
}
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt
index 743bf8781c..ea5a1c6480 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt
@@ -317,8 +317,15 @@ interface MapConsentPrefs {
/** Reactive interface for map tile provider settings. */
interface MapTileProviderPrefs {
val customTileProviders: StateFlow<String?>
+ val selectedCustomTileProviderId: StateFlow<String?>
- fun setCustomTileProviders(providers: String?)
+ suspend fun awaitCustomTileProviders(): String?
+
+ suspend fun awaitSelectedCustomTileProviderId(): String?
+
+ suspend fun setCustomTileProviders(providers: String?)
+
+ suspend fun setSelectedCustomTileProviderId(providerId: String?)
}
/** Reactive interface for radio settings. */
diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt
index 3c325fbc3e..83be3195d8 100644
--- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt
+++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt
@@ -334,10 +334,19 @@ class FakeMapConsentPrefs : MapConsentPrefs {
class FakeMapTileProviderPrefs : MapTileProviderPrefs {
override val customTileProviders = MutableStateFlow<String?>(null)
+ override val selectedCustomTileProviderId = MutableStateFlow<String?>(null)
- override fun setCustomTileProviders(providers: String?) {
+ override suspend fun awaitCustomTileProviders(): String? = customTileProviders.value
+
+ override suspend fun awaitSelectedCustomTileProviderId(): String? = selectedCustomTileProviderId.value
+
+ override suspend fun setCustomTileProviders(providers: String?) {
customTileProviders.value = providers
}
+
+ override suspend fun setSelectedCustomTileProviderId(providerId: String?) {
+ selectedCustomTileProviderId.value = providerId
+ }
}
class FakeRadioPrefs : RadioPrefs {
Served by rngit 1.5.0 - Generated in 0.19s